Skip to content

fix(recovery): refuse the rollback reset when the snapshot fails (#340) - #341

Merged
pbean merged 9 commits into
mainfrom
fix/snapshot-failure-blocks-reset
Jul 28, 2026
Merged

fix(recovery): refuse the rollback reset when the snapshot fails (#340)#341
pbean merged 9 commits into
mainfrom
fix/snapshot-failure-blocks-reset

Conversation

@pbean

@pbean pbean commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Closes #340.

The decision

#340 asked whether a failed snapshot_worktree should block the rollback reset, and recorded three arguments against. Validating them turned up that one is factually wrong, and that the severity is inverted.

rollback_or_pause's auto-recover arm runs preserve_attempt_commitspreserve_attempt_worktreesafe_reset, with the reset unconditional and outside any try. The commits step refuses to reset past work it could not park; the worktree step journaled and let the reset run. So the path that did pause guarded the more recoverable half — orphaned commits stay in the object store, reachable by reflog/git fsck until gc — while the path that did not pause guarded work that a reset --hard destroys permanently.

The issue's counter-argument that this "collides with #161's invariant that an in-worktree rollback never pauses" does not hold: preserve_attempt_commits receives allow_pause=not redrive, so a plain in-worktree dev retry already pauses on a commits-preserve failure, and pause_for_manual_recovery's own comment documents that case and names workspace.root in every emitted command. The real #161 invariant is only that scm.rollback_on_failure does not gate in-worktree recovery, and it is untouched here — both its tests stay green unmodified.

Two corrections to the issue text while validating: the raise surface is nine git spawns, not seven (it misses rev_parse_head and untracked_files), each of which can also fail via _run_git's timeout translation. And this hazard has already been patched once by cause — verify.py synthesizes a git identity precisely so that failure mode can't "reset past the very work this ref exists to preserve", leaving eight other causes open.

What changed

1. preserve_attempt_worktree refuses the reset on the same terms as its sibling. It takes the same required allow_pause, and on a capture failure journals, latches preserve_partial, then pauses — unless a _reset_would_destroy probe says nothing unparked is at stake.

The probe reuses the existing verify.attempt_dirty, called against HEAD rather than the attempt baseline. That one substitution makes it report exactly the tracked edits and run-created untracked files safe_rollback is about to drop, while ignoring commits, which the previous step has already parked or paused on. So a capture failure over a tree whose content was all committed still resets — a git fault must not halt an unattended run over a harmless reset (cf. #123). It fails safe on its own GitError, mirroring the dirty check's doctrine from #156.

New notice shape (d) offers a git-free rescue (copy the files out) rather than a git-based one, since whatever broke the snapshot may still be breaking git, and points at the attempt-worktree-preserve-failed journal entry for the cause.

Blast radius is narrow. The new pause needs (in_unit_worktree or rollback_on_failure) and not redrive. Under the shipped defaults (isolation="none", rollback_on_failure=false) the snapshot is reached only via cause="resolved", which forces allow_pause=False — so this is inert on default config. It bites rollback_on_failure = true and in-worktree dev retries.

#338 is narrowed, not superseded. The re-drive path still journals-and-resets, so preserve_partial and the commits-only defer notice are still load-bearing there. Its regression test moves to cause="resolved" accordingly.

2. safe_rollback fails loud when it cannot take its preserve snapshot. A failed git stash create was swallowed into an empty snapshot, which silently disabled the entire preserve restore — so the hard reset reverted exactly the paths the caller asked to keep (a resolved re-drive's corrected spec) with no error anywhere. Now raises before the reset, scoped to callers that requested a restore so both sweep sites keep degrading.

3. A spawn-level OSError during the snapshot degrades instead of crashing. _run_git translates only a timeout, so an EMFILE/ENOMEM escaped untyped out of the middle of a rollback. Preservation is observation, so it joins the journal-and-decide path; safe_reset is the repair write and still raises.

Three separate commits so each can be judged independently.

Verification

  • Full suite: 3383 passed, 24 skipped
  • trunk check (no path filter): clean
  • pyright 1.1.411 (CI-pinned): 0 errors

All 10 ablations were run and each made its named test fail. Three are inverse ablations, which matter because the assertions they guard are negative and would otherwise pass vacuously:

Ablation Test that must fail
delete the snapshot-failure pause call 5 tests, incl. the engine-level twin
make the pause unconditional (drop the at-risk guard) ..._proceeds_when_nothing_would_be_lost
probe git-fault returns False instead of failing safe ..._probe_fault_pauses
delete the re-drive pause-free early return 3 tests
notice names the main checkout, not the mounted worktree ..._pause_names_the_unit_worktree
narrow the snapshot except back to GitError ..._oserror_degrades_into_the_typed_path
delete the preserve_partial latch (#338) 2 tests
hoist preserve_partial out of the except (#338) ..._dirty_preserve_ref_wins_and_subsumes...
drop the stash-create raise ..._raises_when_the_preserve_snapshot_cannot_be_taken
drop the and preserve scope on that raise ..._degrades_stash_failure_when_nothing_to_preserve

test_rollback_in_unit_worktree_auto_recovers_even_when_off and test_dev_retry_in_worktree_auto_recovers_instead_of_pausing (the #161 pins) are unmodified and green.

Not fixed here

_defer advances the task to Phase.DEFERRED before calling _rollback_or_pause, and DEFERRED is terminal, so a pause there permanently skips the story-deferred journal entry and the defer notification. Pre-existing — reachable today via the commits-preserve pause — and this change widens it to a second path. Impact is forensics only: the pause leaves the tree untouched, so the deferred-work restore has nothing to restore, and the operator gets a louder ACTION REQUIRED notice instead. Filed separately.

Summary by CodeRabbit

  • Changed
    • Auto-rollback now blocks and pauses with rescue instructions when capturing the “preserve uncommitted work” snapshot fails and a reset could destroy local changes.
    • Resolved re-drives remain pause-free even if that uncommitted-work snapshot capture fails.
  • Bug Fixes
    • Preserved rollback restoration is no longer silently ignored when the pre-reset snapshot can’t be created.
    • Snapshot-capture operating system errors no longer crash runs; failures are surfaced and handled consistently.
  • Documentation
    • Updated failure-handling documentation to describe the new pause/refusal behavior and preservation limits.

pbean added 4 commits July 27, 2026 18:02
The two preserve steps were asymmetric: preserve_attempt_commits refused to
reset past commits it could not park, while a failed snapshot_worktree was
journaled and safe_reset ran anyway, destroying the tracked edits and
run-created untracked files the snapshot existed to capture. That guarded the
more recoverable half — orphaned commits survive in the object store until gc,
an uncommitted edit a reset --hard discards does not.

preserve_attempt_worktree now takes the same required allow_pause its sibling
does and refuses on the same terms, behind a _reset_would_destroy probe so a
capture failure over a tree with nothing unparked left to lose still resets
rather than halting an unattended run (#123). The probe reuses attempt_dirty
against HEAD — commits are already parked or paused on by then — and fails safe
on its own git fault (#156).

New notice shape (d) names workspace.root, so an in-worktree pause targets the
mounted tree rather than the operator's checkout (#161), and offers a git-free
rescue: whatever broke the snapshot may still be breaking git.

Closes #340
safe_rollback swallowed a failed `git stash create` into an empty snapshot,
which disabled the entire preserve restore below it. The hard reset then
reverted exactly the paths the caller asked to keep — a resolved re-drive's
corrected spec — with no error raised or journalled anywhere, which is the
regression the preserve argument exists to prevent.

Raise before the reset, scoped to callers that requested a restore: with no
preserve the snapshot is unused, so both sweep call sites keep degrading. A
clean tree still exits rc 0 with empty output and is unaffected.
_run_git translates only a timeout, so a spawn-level EMFILE/ENOMEM from the
snapshot's nine git children escaped as a bare OSError — past every except
GitError guard, out of the middle of a rollback, into the crash handler. The
sibling guards (is_ancestor, worktree_prune) already catch both.

Widen the caller, not the reset: capturing the attempt is observation and may
degrade, so it joins the journal-and-decide path; safe_reset is the repair
write and still raises.
…sections

The first pass created duplicate Changed/Fixed headings inside Unreleased and
dropped the Added heading, orphaning the #333 entry. Caught by markdownlint
MD024.
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@pbean, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 34 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b5e2611a-a264-4a15-be2e-c021a57b105e

📥 Commits

Reviewing files that changed from the base of the PR and between 9edeb5f and bc52fbf.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • src/bmad_loop/recovery_flow.py
  • tests/test_recovery_flow.py

Walkthrough

Changes

Rollback recovery behavior

Layer / File(s) Summary
Preserved rollback snapshot failure handling
src/bmad_loop/verify.py, tests/test_verify.py
safe_rollback now raises when preserving paths and git stash create fails; tests cover both protected and unprotected reset behavior.
Snapshot failure rollback gating
src/bmad_loop/recovery_flow.py, src/bmad_loop/engine.py, tests/test_recovery_flow.py, tests/test_engine.py, docs/FEATURES.md, CHANGELOG.md
Plain rollbacks pause before destructive resets when dirty-work snapshots fail, while resolved re-drives journal and continue without pausing; OSError failures, notices, documentation, and regression tests are updated.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Engine
  participant RecoveryFlow
  participant Snapshot
  participant Operator
  Engine->>RecoveryFlow: rollback_or_pause(task, cause)
  RecoveryFlow->>Snapshot: preserve attempt worktree
  Snapshot-->>RecoveryFlow: success or snapshot failure
  RecoveryFlow->>RecoveryFlow: evaluate reset destruction
  RecoveryFlow->>Operator: provide rescue instructions
  RecoveryFlow-->>Engine: pause or continue re-drive
Loading

Possibly related PRs

Poem

A rabbit guards the working tree,
While rollback waits respectfully.
Snapshots fail; no edits flee,
Re-drives hop on carefully.
Rescue notes bloom beside the tree.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.73% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main behavioral change: rollback now refuses the reset when snapshot preservation fails.
Linked Issues check ✅ Passed The PR implements #340 by pausing rollback when a failed worktree snapshot would destroy unparked work, while keeping re-drives pause-free.
Out of Scope Changes check ✅ Passed The code changes stay focused on rollback/preservation recovery behavior, its notices, docs, and tests, with no unrelated scope evident.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/snapshot-failure-blocks-reset

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/test_recovery_flow.py (1)

430-437: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: reuse the helper in the earlier test.

test_snapshot_failure_leaves_a_commits_only_ref_flagged_partial (lines 410-413) still hand-rolls the identical _fail + monkeypatch.setattr. Since _fail_snapshot now exists, collapsing that one keeps a single definition of "the snapshot blew up".

♻️ Proposed cleanup (move `_fail_snapshot` above line 386, then)
-    def _fail(*_a, **_k):
-        raise verify.GitError("simulated commit-tree failure")
-
-    monkeypatch.setattr(verify, "snapshot_worktree", _fail)
+    _fail_snapshot(monkeypatch)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_recovery_flow.py` around lines 430 - 437, Reuse the existing
_fail_snapshot helper in
test_snapshot_failure_leaves_a_commits_only_ref_flagged_partial instead of
defining another failure callback and monkeypatching verify.snapshot_worktree
inline. Move _fail_snapshot earlier if needed so both tests can call the single
shared helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/bmad_loop/recovery_flow.py`:
- Around line 460-464: Update the exception handler in the probe flow
surrounding verify.rev_parse_head and verify.attempt_dirty to catch both
verify.GitError and OSError, matching preserve_attempt_worktree and preserving
the fail-safe return True behavior. Also update the related docstring to
explicitly mention OSError as an indeterminate probe failure.

---

Nitpick comments:
In `@tests/test_recovery_flow.py`:
- Around line 430-437: Reuse the existing _fail_snapshot helper in
test_snapshot_failure_leaves_a_commits_only_ref_flagged_partial instead of
defining another failure callback and monkeypatching verify.snapshot_worktree
inline. Move _fail_snapshot earlier if needed so both tests can call the single
shared helper.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 408685b6-7bef-447e-a322-2551e553a320

📥 Commits

Reviewing files that changed from the base of the PR and between e60252b and 520a81c.

📒 Files selected for processing (8)
  • CHANGELOG.md
  • docs/FEATURES.md
  • src/bmad_loop/engine.py
  • src/bmad_loop/recovery_flow.py
  • src/bmad_loop/verify.py
  • tests/test_engine.py
  • tests/test_recovery_flow.py
  • tests/test_verify.py

Comment thread src/bmad_loop/recovery_flow.py
@greptile-apps

greptile-apps Bot commented Jul 28, 2026

Copy link
Copy Markdown

Greptile Summary

This PR inverts the contract for preserve_attempt_worktree from best-effort to a gate: a failed uncommitted-work snapshot now refuses the rollback reset rather than silently proceeding, matching the protection already in place for committed work. It also hardens every git subprocess call on the rollback path against spawn-level OSError (previously only GitError/translated timeouts were caught), and fixes a silent data-loss path in safe_rollback where a failed git stash create was swallowed into an empty snapshot that disabled the preserve restore entirely.

  • preserve_attempt_worktree gains allow_pause and calls the new _reset_would_destroy probe (dirty-vs-HEAD via attempt_dirty) to gate the pause on whether anything is actually at stake, so a snapshot fault over a clean tree still resets rather than halting an unattended run; (GitError, OSError) is caught throughout since _run_git only translates timeouts.
  • preserve_attempt_commits now wraps both commits_above and rev_parse_head in a guarded try block; an un-enumerable range takes the preservation-failure path (pause or journal), never the clean-tree early return.
  • safe_rollback raises before the reset --hard when git stash create fails and a preserve restore was requested, closing a path where a corrected re-drive spec was silently reverted.

Confidence Score: 5/5

Safe to merge; the three code changes are independent, each guarded by an inverse ablation test, and the full suite of 3383 tests is green.

The gate-vs-safety-net inversion is narrowly scoped (inert on default config, biting only rollback_on_failure=true and in-worktree dev retries), the _reset_would_destroy probe correctly probes dirty-vs-HEAD rather than dirty-vs-baseline so committed-work-only trees still reset cleanly, and both the OSError-broadening and the stash create raise are scoped tightly to the paths that need them. Previously flagged gaps — OSError uncaught in _reset_would_destroy and in the advisory commits_above probe inside pause_for_manual_recovery — are both resolved in this diff.

Files Needing Attention: No files require special attention.

Important Files Changed

Filename Overview
src/bmad_loop/recovery_flow.py Core of the PR: preserve_attempt_worktree gains allow_pause, _reset_would_destroy probe added, preserve_attempt_commits now guards both git calls, and the advisory probe in pause_for_manual_recovery correctly broadened to catch (GitError, OSError) — all consistently fail-safe.
src/bmad_loop/verify.py safe_rollback now raises on stash create failure when preserve is set (scoped correctly to avoid breaking sweep callers); snapshot_worktree docstring updated to reflect the gate-not-safety-net contract; clean tree path (rc=0, empty output) unaffected.
src/bmad_loop/engine.py Thin forwarding layer updated to pass allow_pause and snapshot_failed through to RecoveryFlow; no logic lives here, just signature parity with the implementation.
tests/test_recovery_flow.py Comprehensive test additions: snapshot-fail pause, no-pause when nothing at risk (inverse ablation), probe fault fails safe, OSError degrades, re-drive never pauses, unit-worktree notice names the right root, notice probe OSError doesn't swallow the pause — all 10 ablations covered per the PR table.
tests/test_engine.py Engine-level end-to-end twin for #340 added; existing test correctly moved to cause="resolved" path since a plain rollback now pauses rather than resets on snapshot failure.
tests/test_verify.py Two new tests for safe_rollback: one confirms it raises before the reset when stash create fails with preserve set, the other (inverse ablation) confirms it degrades when no preserve was requested.
docs/FEATURES.md Documentation updated to describe the new gate-not-safety-net behavior and note that the commits-only defer flag is now reachable only on a re-drive since #340; accurate.
CHANGELOG.md Changelog entries for #340, #343 fixes, and safe_rollback stash-create fix are clear and accurate; blast-radius note (inert under shipped defaults) is correctly stated.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[preserve_attempt_worktree called] --> B{baseline_commit set?}
    B -- No --> Z[return no-op]
    B -- Yes --> C[snapshot_worktree]

    C -- success, parked=None --> D[return tree clean vs HEAD]
    C -- success, parked=ref --> E[task.preserve_ref = ref
journal attempt-worktree-preserved]
    E --> Z

    C -- GitError or OSError --> F[journal attempt-worktree-preserve-failed
task.preserve_partial = True]
    F --> G{allow_pause?}
    G -- False re-drive --> Z2[return journal-and-proceed
safe_reset runs next]
    G -- True plain rollback --> H[_reset_would_destroy probe
rev_parse_head + attempt_dirty vs HEAD]

    H -- GitError or OSError --> I[fail-safe: return True]
    H -- False tree clean vs HEAD --> J[return
safe_reset runs harmless reset]
    H -- True uncommitted work at risk --> K[pause_for_manual_recovery
snapshot_failed=True
always raises RunPaused]

    K --> L[Notice shape d:
name working tree,
offer copy-out rescue,
point at journal breadcrumb]
Loading

Reviews (6): Last reviewed commit: "fix(recovery): escalate on an OSError fr..." | Re-trigger Greptile

Comment thread src/bmad_loop/recovery_flow.py
pbean added 2 commits July 27, 2026 18:26
Found by CodeRabbit and Greptile independently on #341. The previous commit
widened preserve_attempt_worktree to catch OSError, but _reset_would_destroy —
which runs immediately after that fault, against the same git binary — still
guarded verify.GitError alone. The EMFILE/ENOMEM that broke the capture is
exactly what then breaks rev_parse_head/attempt_dirty, so the errno escaped the
probe and crashed the rollback anyway.

The asymmetry between the sibling handlers was the defect, not either half.

Also collapse the duplicated snapshot-failure stub onto _fail_snapshot.
…ause

Third link in the same chain, found by Greptile on #341. pause_for_manual_
recovery probes commits_above to choose its notice shape, guarded by a comment
saying a git fault there must not block the pause itself — but it caught only
verify.GitError, so an untranslated OSError lost the pause the caller had
already decided to take.

That probe is likelier to hit EMFILE on the new snapshot_failed path than
anywhere else: the fault that broke the capture is still in force when we come
to write the notice. Degrading to "no commits" costs only notice shape; the
crash cost the pause.
@pbean

pbean commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Third finding confirmed and fixed in 25ffb3c — the commits_above probe inside pause_for_manual_recovery.

Valid, and the sharpest of the three: that probe's own comment already declared the contract ("advisory probe: a git fault here must not block the pause itself") and the code did not honour it for OSError. It is also the likeliest of the three to fire, because it runs while the EMFILE/ENOMEM that broke the capture is still in force — so the failure it drops is the pause the caller had already decided to take. Degrading to "no commits" costs only the notice shape; crashing cost the pause.

Covered by test_notice_probe_oserror_does_not_swallow_the_pause, which lets preserve_attempt_commits' own commits_above call succeed and fails only the advisory one, so the test can't pass by aborting earlier. Ablated: narrowing that except back to verify.GitError makes it fail with the raw OSError.

Two process notes, because both nearly shipped a false green:

  1. My ablation harness reverted via git checkout --, which destroyed an uncommitted fix mid-run; every later ablation then verified source that no longer contained it. It now snapshots on-disk content and restores from memory.

  2. More subtle: ablations that delete the same number of characters produce identical file sizes, and CPython validates a .pyc against (source_mtime_to_the_second, source_size). Two such ablations writing within the same clock second make the second run silently reimport the first's bytecode — the gate looks intact and the ablation reports a false pass. This is exactly what happened between the _reset_would_destroy and commits_above ablations (both remove 11 characters): the run was ~50% flaky. Fixed by dropping src/**/__pycache__ before each run and setting PYTHONDONTWRITEBYTECODE=1; 12/12 now bite deterministically across five consecutive runs.

Worth flagging for anyone else ablation-testing here — a size-preserving ablation plus sub-second writes is a silent false-negative generator, and it fails in the safe-looking direction.

On the pattern rather than the instance: three rounds found the same defect one frame further along the same chain, which says the per-site defence isn't the right shape. _run_git translates only TimeoutExpired, so a spawn-level OSError bypasses every except GitError in the codebase — 21 of 24 such guards are currently blind to it. Filed as #343 with a proposed chokepoint translation, rather than continuing to widen guards one review comment at a time. Out of scope here; #341 stays scoped to the three sites on the path it created.

pbean added 3 commits July 27, 2026 19:16
snapshot_worktree's docstring still described the best-effort "journal and
proceed" contract that #340 reversed, contradicting preserve_attempt_worktree
in the same change. It also promised GitError on any failure, though _run_git
translates only a timeout and the TemporaryDirectory can raise OSError before
any git child is spawned.

Also qualify "both paths now refuse on the same terms": the symmetry is in the
refusal policy, not the guard — preserve_attempt_commits still catches GitError
alone, so a spawn fault there crashes instead of refusing (#343).
preserve_attempt_commits read the range above baseline with no try at all,
so an ordinary git timeout — which _run_git does translate, and which every
sibling on this path already treats as routine — crashed the run mid-rollback.
A spawn-level OSError did the same there and at the ref write, and at the
rollback's dirty check one frame earlier.

An un-determinable range now takes the preservation-failure path rather than
the `not commits` early return, which would have let the reset run blind: it
refuses under allow_pause and journals on a re-drive, matching the contract an
un-parked ref already followed. Journalled as attempt-preserve-enumerate-failed
so a post-mortem can distinguish it from a ref that would not take — only the
latter can report a HEAD.

These are the four frames upstream of the three #341 already covered; the
repo-wide question of translating OSError at the _run_git chokepoint stays
open in #343.

Tests parametrize over exception *factories*: shared instances are built once
at collection and re-raising mutates __traceback__, which under pytest-randomly
made the cases couple and fail for the wrong reason.
The last OSError-blind guard in the collaborator. apply_patch reads the patch
from disk, so an ENOENT/EACCES/ENOSPC arrives untyped and crashed straight past
the escalation this branch exists to perform — leaving a half-restored tree
with no attention file, on the dispatch path rather than the rollback one.

recovery_flow.py is now uniformly OSError-safe; the remaining 19 blind guards
across 8 other modules stay with the chokepoint question in #343.
@pbean
pbean merged commit af30dc1 into main Jul 28, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Decide whether a failed worktree snapshot should block the rollback reset

1 participant